充值信仰,已买实体书。重温一下,顺便记录。这篇博客主要涉及0-11章的阅读记录。主要内容是在原有的基本数据类型的基础上新增的方法。主要的记录形式是整理成思维导图,方便复习,比较长的代码或者文字部分进行标注补充。
ES6 允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。
本质上,这种写法属于“模式匹配”,只要等号两边的模式相同,左边的变量就会被赋予对应的值。
简单实例:
1 | let [foo, [[bar], baz]] = [1, [[2], 3]]; |
解构不成功,变量的值就等于undefined
1 | let [x, y, ...z] = ['a']; |
不完全解构,即等号左边的模式,只匹配一部分的等号右边的数组
1 | let [x, y] = [1, 2, 3]; |
报错, 右边不是数组(或者严格地说,不是可遍历的结构)
1 | let [foo] = 1; |
数据结构具有 Iterator 接口,都可以采用数组形式的解构赋值
1 | let [x, y, z] = new Set(['a', 'b', 'c']); |
默认值(只有当一个数组成员严格等于undefined,默认值才会生效)
1 | let [foo = true] = []; |
其他
对象的解构与数组有一个重要的不同。数组的元素是按次序排列的,变量的取值由它的位置决定;而对象的属性没有次序,变量必须与属性同名,才能取到正确的值。
对象的解构赋值的内部机制,是先找到同名属性,然后再赋给对应的变量。真正被赋值的是后者,而不是前者。
简单实例
1 | let { bar, foo } = { foo: 'aaa', bar: 'bbb' }; |
对象的解构赋值,可以很方便地将现有对象的方法,赋值到某个变量
1 | let { foo: baz } = { foo: 'aaa', bar: 'bbb' }; |
如果变量名与属性名不一致,必须写成下面这样
1 | let { foo: baz } = { foo: 'aaa', bar: 'bbb' }; |
嵌套结构
1 | const node = { |
上面代码有三次解构赋值,分别是对loc、start、line三个属性的解构赋值。注意,最后一次对line属性的解构赋值之中,只有line是变量,loc和start都是模式,不是变量。
1 | // 错误的写法 |
JavaScript 引擎会将{x}理解成一个代码块,从而发生语法错误。只有不将大括号写在行首,避免 JavaScript 将其解释为代码块,才能解决这个问题。
解构赋值允许等号左边的模式之中,不放置任何变量名。虽然毫无意义,但是语法是合法的。
由于数组本质是特殊的对象,因此可以对数组进行对象属性的解构
1 | let arr = [1, 2, 3]; |
字符串也可以解构赋值。这是因为此时,字符串被转换成了一个类似数组的对象。
简单实例
1 | const [a, b, c, d, e] = 'hello'; |
解构赋值时,如果等号右边是数值和布尔值,则会先转为对象。
1 | let {toString: s} = 123; |
1 | function move({x = 0, y = 0} = {}) { |
ES6 的规则是,只要有可能导致解构的歧义,就不得使用圆括号。
可以使用圆括号的情况只有一种:赋值语句的非模式部分,可以使用圆括号。
1 | function move({x, y} = { x: 0, y: 0 }) { |
1 | let x = 1; |
函数只能返回一个值,如果要返回多个值,只能将它们放在数组或对象里返回。有了解构赋值,取出这些值就非常方便。
解构赋值可以方便地将一组参数与变量名对应起来。
1 | let jsonData = { |
函数参数的默认值
遍历 Map 结构
1 | const map = new Map(); |
1 | for (let codePoint of 'foo') { |
1 | let text = String.fromCodePoint(0x20BB7); |
JavaScript 字符串允许直接输入字符,以及输入字符的转义形式。但是,JavaScript 规定有5个字符,不能在字符串里面直接使用,只能使用转义形式。
这个规定本身没有问题,麻烦在于 JSON 格式允许字符串里面直接使用 U+2028(行分隔符)和 U+2029(段分隔符)。这样一来,服务器输出的 JSON 被JSON.parse解析,就有可能直接报错。
1 | const json = '"\u2028"'; |
为了消除这个报错,ES2019 允许 JavaScript 字符串直接输入 U+2028(行分隔符)和 U+2029(段分隔符)。
1 | const PS = eval("'\u2029'"); // 不会报错 |
注意,模板字符串现在就允许直接输入这两个字符。另外,正则表达式依然不允许直接输入这两个字符,这是没有问题的,因为 JSON 本来就不允许直接包含正则表达式。
为了确保返回的是合法的 UTF-8 字符,ES2019 改变了JSON.stringify()的行为。如果遇到0xD800到0xDFFF之间的单个码点,或者不存在的配对形式,它会返回转义字符串,留给应用自己决定下一步的处理。
1 | JSON.stringify('\u{D834}') // ""\\uD834"" |
模板字符串的功能,不仅仅是上面这些。它可以紧跟在一个函数名后面,该函数将被调用来处理这个模板字符串。这被称为“标签模板”功能(tagged template)。
1 | alert`123` |
如果模板字符里面有变量,会将模板字符串先处理成多个参数,再调用函数。
1 | let a = 5; |
tag函数的第一个参数是一个数组,该数组的成员是模板字符串中那些没有变量替换的部分,也就是说,变量替换只发生在数组的第一个成员与第二个成员之间、第二个成员与第三个成员之间,以此类推。
tag函数的其他参数,都是模板字符串各个变量被替换后的值。由于本例中,模板字符串含有两个变量,因此tag会接受到value1和value2两个参数。
“标签模板”的一个重要应用,就是过滤 HTML 字符串,防止用户输入恶意内容。
标签模板的另一个应用,就是多语言转换(国际化处理)
你甚至可以使用标签模板,在 JavaScript 语言之中嵌入其他语言
1 | jsx` |
模板字符串默认会将字符串转义,导致无法嵌入其他语言。(模板字符串的限制)
ES2018 放松了对标签模板里面的字符串转义的限制。如果遇到不合法的字符串转义,就返回undefined,而不是报错,并且从raw属性上面可以得到原始字符串。
1 | function tag(strs) { |
注意,这种对字符串转义的放松,只在标签模板解析字符串时生效,不是标签模板的场合,依然会报错。
1 | let bad = `bad escape sequence: \unicode`; // 报错 |
1 | String.fromCodePoint(0x20BB7) |
作为处理模板字符串的基本方法,它会将所有变量替换,而且对斜杠进行转义(斜杠前面再加斜杠),方便下一步作为字符串来使用。
1 | String.raw`Hi\n${2+3}!`; |
可以作为正常的函数使用。第一个参数是一个具有raw属性的对象,且raw属性的值应该是一个数组。
1 | String.raw({ raw: 'test' }, 0, 1, 2); |
能够正确处理 4 个字节储存的字符,返回一个字符的码点。
1 | let s = '𠮷a'; |
codePointAt()方法返回的是码点的十进制值,如果想要十六进制的值,可以使用toString()方法转换一下。
1 | let s = '𠮷a'; |
上面代码中,字符a在字符串s的正确位置序号应该是 1,但是必须向codePointAt()方法传入 2。解决方法有如下两种:
1 | let s = '𠮷a'; |
1 | let arr = [...'𠮷a']; // arr.length === 2 |
codePointAt()方法是测试一个字符由两个字节还是由四个字节组成的最简单方法
1 | function is32Bit(c) { |
许多欧洲语言有语调符号和重音符号。为了表示它们,Unicode 提供了两种方法。一种是直接提供带重音符号的字符,比如Ǒ(\u01D1)。另一种是提供合成符号(combining character),即原字符与重音符号的合成,两个字符合成一个字符,比如O(\u004F)和ˇ(\u030C)合成Ǒ(\u004F\u030C)。
这两种表示方法,在视觉和语义上都等价,但是 JavaScript 不能识别。
normalize()方法,用来将字符的不同表示方法统一为同样的形式,这称为 Unicode 正规化。
1 | '\u01D1'==='\u004F\u030C' //false |
normalize方法可以接受一个参数来指定normalize的方式,参数的四个可选值如下(不赘述)
normalize方法目前不能识别三个或三个以上字符的合成。
ES6 对正则表达式添加了u修饰符,含义为“Unicode 模式”,用来正确处理大于\uFFFF的 Unicode 字符。也就是说,会正确处理四个字节的 UTF-16 编码。
1 | /^\uD83D/u.test('\uD83D\uDC2A') // false,支持,会识别其为一个字符 |
一旦加上u修饰符号,就会修改下面这些正则表达式的行为。
对于码点大于0xFFFF的 Unicode 字符,点字符不能识别,必须加上u修饰符。
1 | var s = '𠮷'; |
ES6 新增了使用大括号表示 Unicode 字符,这种表示法在正则表达式中必须加上u修饰符,才能识别当中的大括号,否则会被解读为量词。
1 | /\u{61}/.test('a') // false |
使用u修饰符后,所有量词都会正确识别码点大于0xFFFF的 Unicode 字符。
1 | /a{2}/.test('aa') // true |
\S是预定义模式,匹配所有非空白字符。只有加了u修饰符,它才能正确匹配码点大于0xFFFF的 Unicode 字符。
1 | /^\S$/.test('𠮷') // false |
有些 Unicode 字符的编码不同,但是字型很相近,比如,\u004B与\u212A都是大写的K。
1 | /[a-z]/i.test('\u212A') // false |
正则表达式中,点(.)是一个特殊字符,代表任意的单个字符,但是有两个例外。一个是四个字节的 UTF-16 字符,这个可以用u修饰符解决;另一个是行终止符(line terminator character)。
所谓行终止符,就是该字符表示一行的终结。以下四个字符属于“行终止符”。
很多时候我们希望匹配的是任意单个字符,这时有一种变通的写法。
1 | /foo[^]bar/.test('foo\nbar') |
这种解决方案毕竟不太符合直觉,ES2018 引入s修饰符,使得.可以匹配任意单个字符。
1 | /foo.bar/s.test('foo\nbar') // true |
这被称为dotAll模式,即点(dot)代表一切字符。所以,正则表达式还引入了一个dotAll属性,返回一个布尔值,表示该正则表达式是否处在dotAll模式。
1 | const re = /foo.bar/s; |
“先行断言”指的是,x只有在y前面才匹配,必须写成/x(?=y)/。
“先行否定断言”指的是,x只有不在y前面才匹配,必须写成/x(?!y)/。
1 | /\d+(?=%)/.exec('100% of US presidents have been male') // ["100"] |
“后行断言”正好与“先行断言”相反,x只有在y后面才匹配,必须写成/(?<=y)x/。
“后行否定断言”则与“先行否定断言”相反,x只有不在y后面才匹配,必须写成/(?<!y)x/。
“后行断言”的实现,需要先匹配/(?<=y)x/的x,然后再回到左边,匹配y的部分。这种“先右后左”的执行顺序,与所有其他正则操作相反,导致了一些不符合预期的行为。
其次,“后行断言”的反斜杠引用,也与通常的顺序相反,必须放在对应的那个括号之前。
暂不赘述
例子:
1 | // 匹配所有数字 |
正则表达式使用圆括号进行组匹配。使用exec方法,就可以将这三组匹配结果提取出来。但每一组的匹配含义不容易看出来,而且只能用数字序号(比如matchObj[1])引用,要是组的顺序变了,引用的时候就必须修改序号。
1 | const RE_DATE = /(\d{4})-(\d{2})-(\d{2})/; |
具名组匹配(Named Capture Groups),允许为每一个组匹配指定一个名字,既便于阅读代码,又便于引用。
1 | const RE_DATE = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/; |
如果具名组没有匹配,那么对应的groups对象属性会是undefined。
有了具名组匹配以后,可以使用解构赋值直接从匹配结果上为变量赋值。
1 | let {groups: {one, two}} = /^(?<one>.*):(?<two>.*)$/u.exec('foo:bar'); |
字符串替换时,使用$<组名>
引用具名组。
1 | // replace方法的第二个参数是一个字符串,而不是正则表达式 |
如果要在正则表达式内部引用某个“具名组匹配”,可以使用\k<组名>
的写法。
1 | const RE_TWICE = /^(?<word>[a-z]+)!\k<word>$/; |
ES6 允许为函数的参数设置默认值,即直接写在参数定义的后面。
1 | function log(x, y = 'World') { |
参数变量是默认声明的,所以不能用let或const再次声明,不能有同名参数。
另外,一个容易忽略的地方是,参数默认值不是传值的,而是每次都重新计算默认值表达式的值。也就是说,参数默认值是惰性求值的。
1 | let x = 99; |
1 | function foo({x, y = 5}) { |
1 | function fetch(url, { body = '', method = 'GET', headers = {} }) { |
1 | // 写法一 |
通常情况下,定义了默认值的参数,应该是函数的尾参数。因为这样比较容易看出来,到底省略了哪些参数。如果非尾部的参数设置默认值,实际上这个参数是没法省略的。
1 | // 例一 |
指定了默认值以后,函数的length属性,将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length属性将失真。
1 | (function (a) {}).length // 1 |
这是因为length属性的含义是,该函数预期传入的参数个数。某个参数指定默认值以后,预期传入的参数个数就不包括这个参数了。同理,后文的 rest 参数也不会计入length属性。
1 | (function(...args) {}).length // 0 |
如果设置了默认值的参数不是尾参数,那么length属性也不再计入后面的参数了。
1 | (function (a = 0, b, c) {}).length // 0 |
一旦设置了参数的默认值,函数进行声明初始化时,参数会形成一个单独的作用域(context)。等到初始化结束,这个作用域就会消失。这种语法行为,在不设置参数默认值时,是不会出现的。
1 | var x = 1; |
利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误。
1 | function throwIfMissing() { |
另外,可以将参数默认值设为undefined,表明这个参数是可以省略的。
1 | function foo(optional = undefined) { ··· } |
ES6 引入 rest 参数(形式为…变量名),用于获取函数的多余参数,这样就不需要使用arguments对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。
1 | function add(...values) { |
arguments对象不是数组,而是一个类似数组的对象。所以为了使用数组的方法,必须使用Array.prototype.slice.call先将其转为数组。rest 参数就是一个真正的数组,数组特有的方法都可以使用。
注意,rest 参数之后不能再有其他参数(即只能是最后一个参数),否则会报错。
函数的length属性,不包括 rest 参数。
ES2016规定只要函数参数使用了默认值、解构赋值、或者扩展运算符,那么函数内部就不能显式设定为严格模式,否则会报错。
这样规定的原因是,函数内部的严格模式,同时适用于函数体和函数参数。但是,函数执行的时候,先执行函数参数,然后再执行函数体。这样就有一个不合理的地方,只有从函数体之中,才能知道参数是否应该以严格模式执行,但是参数却应该先于函数体执行。
两种方法可以规避这种限制。
第一种是设定全局性的严格模式,这是合法的。
1 | ; |
第二种是把函数包在一个无参数的立即执行函数里面。
1 | const doSomething = (function () { |
函数的name属性,返回该函数的函数名。
1 | function foo() {} |
1 | var f = () => 5; |
由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号,否则会报错。
1 | let getTempItem = id => ({ id: id, name: "Temp" }); |
与变量解构结合使用
1 | const full = ({ first, last }) => first + ' ' + last; |
rest 参数与箭头函数结合的例子
1 | const numbers = (...nums) => nums; |
1 | function Timer() { |
正是因为它没有this,所以也就不能用作构造函数。
除了this,以下三个变量在箭头函数之中也是不存在的,指向外层函数的对应变量:arguments、super、new.target。
由于箭头函数没有自己的this,所以当然也就不能用call()、apply()、bind()这些方法去改变this的指向。
由于箭头函数使得this从“动态”变成“静态”(this不可变),下面两个场合不应该使用箭头函数。
第一个场合是定义对象的方法,且该方法内部包括this。
1 | const cat = { |
如果是普通函数,该方法内部的this指向cat;如果写成上面那样的箭头函数,使得this指向全局对象,因此不会得到预期结果。这是因为对象不构成单独的作用域,导致jumps箭头函数定义时的作用域就是全局作用域。
第二个场合是需要动态this的时候,也不应使用箭头函数。
1 | var button = document.getElementById('press'); |
因为button的监听函数是一个箭头函数,导致里面的this就是全局对象。如果改成普通函数,this就会动态指向被点击的按钮对象。
尾调用(Tail Call)是函数式编程的一个重要概念,就是指某个函数的最后一步是调用另一个函数。
1 | function f(x){ |
尾调用不一定出现在函数尾部,只要是最后一步操作即可。
1 | // 上面代码中,函数m和n都属于尾调用,因为它们都是函数f的最后一步操作。 |
我们知道,函数调用会在内存形成一个“调用记录”,又称“调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用帧上方,还会形成一个B的调用帧。等到B运行结束,将结果返回到A,B的调用帧才会消失。如果函数B内部还调用函数C,那就还有一个C的调用帧,以此类推。所有的调用帧,就形成一个“调用栈”(call stack)。
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。
这就叫做“尾调用优化”(Tail call optimization),即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用帧只有一项,这将大大节省内存。这就是“尾调用优化”的意义。
函数调用自身,称为递归。如果尾调用自身,就称为尾递归。
递归非常耗费内存,因为需要同时保存成千上百个调用帧,很容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生“栈溢出”错误。
1 | // 非尾递归的 Fibonacci 数列实现如下。 |
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。
方法一是在尾递归函数之外,再提供一个正常形式的函数。
1 | function tailFactorial(n, total) { |
函数式编程有一个概念,叫做柯里化(currying),意思是将多参数的函数转换成单参数的形式。这里也可以使用柯里化。
1 | function currying(fn, n) { |
第二种方法就简单多了,就是采用 ES6 的函数默认值。
1 | function factorial(n, total = 1) { |
ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。
这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈。
func.arguments:返回调用时函数的参数。
func.caller:返回调用当前函数的那个函数。
尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。
正常模式下,自己实现尾递归优化,就是采用“循环”换掉“递归”。
ES2017 允许函数的最后一个参数有尾逗号(trailing comma)。新的语法允许定义和调用时,尾部直接有一个逗号。(主要是为了方便,和功能无关)